Aller au contenu principal

FutureBuilder et StreamBuilder en Flutter

Introduction

Les widgets FutureBuilder et StreamBuilder sont des outils essentiels pour gérer les données asynchrones dans Flutter. Ils permettent de construire l'interface utilisateur de manière réactive en fonction de l'état des opérations asynchrones.

FutureBuilder

Le FutureBuilder est utilisé pour construire un widget en fonction de l'état d'un Future. Il reconstruit automatiquement l'interface lorsque le Future change d'état.

Syntaxe de base

FutureBuilder<T>(
future: monFuture,
builder: (context, snapshot) {
// Construction du widget basé sur snapshot
},
)

Les états du snapshot

Le AsyncSnapshot contient des informations sur l'état actuel du Future :

PropriétéTypeDescription
connectionStateConnectionStateÉtat de la connexion (none, waiting, active, done)
hasDataboolVrai si des données sont disponibles
hasErrorboolVrai si une erreur s'est produite
dataT?Les données retournées par le Future
errorObject?L'erreur si elle existe

États de connexion

enum ConnectionState {
none, // Pas de Future assigné
waiting, // Future en cours d'exécution
active, // Pour les Streams uniquement
done, // Future complété (avec succès ou erreur)
}

Exemple simple

main.dart
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return MaterialApp(
title: 'FutureBuilder Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ProductPage(),
);
}
}

class ProductPage extends StatefulWidget {
const ProductPage({super.key});


State<ProductPage> createState() => _ProductPageState();
}

class _ProductPageState extends State<ProductPage> {
late Future<String> _futureProduit;


void initState() {
super.initState();
_futureProduit = recupererNomProduit();
}

Future<String> recupererNomProduit() async {
await Future.delayed(const Duration(seconds: 2));
return "iPhone 15 Pro";
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Produit")),
body: Center(
child: FutureBuilder<String>(
future: _futureProduit,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
} else if (snapshot.hasData) {
return Text(
snapshot.data!,
style: Theme.of(context).textTheme.headlineMedium,
);
} else {
return const Text("Aucune donnée");
}
},
),
),
);
}
}

Exemple avec API REST

main.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return MaterialApp(
title: 'API REST FutureBuilder',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const ProductListPage(),
);
}
}

class Product {
final int id;
final String title;
final double price;
final String image;

Product({
required this.id,
required this.title,
required this.price,
required this.image,
});

factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int,
title: json['title'] as String,
price: (json['price'] as num).toDouble(),
image: json['image'] as String,
);
}
}

class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});

Future<List<Product>> recupererProduits() async {
final response = await http.get(
Uri.parse('https://fakestoreapi.com/products'),
);

if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body) as List;
return jsonList.map((json) => Product.fromJson(json as Map<String, dynamic>)).toList();
} else {
throw Exception('Erreur ${response.statusCode}');
}
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Produits")),
body: FutureBuilder<List<Product>>(
future: recupererProduits(),
builder: (context, snapshot) {
// État de chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Chargement des produits..."),
],
),
);
}

// État d'erreur
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text("Erreur : ${snapshot.error}"),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
(context as Element).markNeedsBuild();
},
child: const Text("Réessayer"),
),
],
),
);
}

// État avec données
if (snapshot.hasData) {
final produits = snapshot.data!;

if (produits.isEmpty) {
return const Center(child: Text("Aucun produit disponible"));
}

return ListView.builder(
itemCount: produits.length,
itemBuilder: (context, index) {
final produit = produits[index];
return ListTile(
leading: Image.network(
produit.image,
width: 50,
height: 50,
fit: BoxFit.cover,
),
title: Text(produit.title),
subtitle: Text('\$${produit.price.toStringAsFixed(2)}'),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {},
);
},
);
}

// État par défaut
return const Center(child: Text("Aucune donnée"));
},
),
);
}
}

Bonne pratique : Future en dehors du build

Mauvais : Le Future est recréé à chaque rebuild


Widget build(BuildContext context) {
return FutureBuilder(
future: recupererDonnees(), // Recréé à chaque rebuild !
builder: (context, snapshot) {
// ...
},
);
}

Bon : Le Future est créé une seule fois

class MyWidget extends StatefulWidget {

_MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
late Future<List<Product>> _futureProduits;


void initState() {
super.initState();
_futureProduits = recupererDonnees(); // Créé une seule fois
}


Widget build(BuildContext context) {
return FutureBuilder(
future: _futureProduits,
builder: (context, snapshot) {
// ...
},
);
}
}

StreamBuilder

Le StreamBuilder est utilisé pour construire un widget en fonction des données émises par un Stream. Contrairement au FutureBuilder qui gère une seule valeur, le StreamBuilder gère un flux continu de données.

Syntaxe de base

StreamBuilder<T>(
stream: monStream,
initialData: valeurInitiale, // Optionnel
builder: (context, snapshot) {
// Construction du widget basé sur snapshot
},
)

Exemple simple

main.dart
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return MaterialApp(
title: 'StreamBuilder Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const CounterPage(),
);
}
}

class CounterPage extends StatelessWidget {
const CounterPage({super.key});

// Stream qui émet un nombre chaque seconde
Stream<int> compteur() async* {
for (int i = 1; i <= 10; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i;
}
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Compteur")),
body: Center(
child: StreamBuilder<int>(
stream: compteur(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
} else if (snapshot.hasData) {
return Text(
'${snapshot.data}',
style: Theme.of(context).textTheme.displayLarge,
);
} else {
return const Text("En attente...");
}
},
),
),
);
}
}

Exemple : Mises à jour en temps réel

main.dart
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return MaterialApp(
title: 'Météo StreamBuilder',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
),
home: const WeatherPage(),
);
}
}

class WeatherUpdate {
final double temperature;
final String condition;
final DateTime timestamp;

WeatherUpdate({
required this.temperature,
required this.condition,
required this.timestamp,
});
}

class WeatherPage extends StatelessWidget {
const WeatherPage({super.key});

Stream<WeatherUpdate> meteoEnTempsReel() async* {
while (true) {
await Future.delayed(const Duration(seconds: 3));

// Simule des données météo aléatoires
yield WeatherUpdate(
temperature: 15 + (DateTime.now().second % 10).toDouble(),
condition: ['Ensoleillé', 'Nuageux', 'Pluvieux'][DateTime.now().second % 3],
timestamp: DateTime.now(),
);
}
}

IconData _getWeatherIcon(String condition) {
switch (condition) {
case 'Ensoleillé':
return Icons.wb_sunny;
case 'Nuageux':
return Icons.cloud;
case 'Pluvieux':
return Icons.umbrella;
default:
return Icons.help;
}
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Météo en temps réel")),
body: Center(
child: StreamBuilder<WeatherUpdate>(
stream: meteoEnTempsReel(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Connexion à la station météo..."),
],
);
}

if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
}

if (!snapshot.hasData) {
return const Text("En attente de données...");
}

final meteo = snapshot.data!;

return Card(
elevation: 4,
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getWeatherIcon(meteo.condition),
size: 100,
color: Colors.blue,
),
const SizedBox(height: 16),
Text(
'${meteo.temperature.toStringAsFixed(1)}°C',
style: Theme.of(context).textTheme.displaySmall,
),
Text(
meteo.condition,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Mis à jour: ${meteo.timestamp.hour}:${meteo.timestamp.minute}:${meteo.timestamp.second}',
style: const TextStyle(color: Colors.grey),
),
],
),
),
);
},
),
),
);
}
}

Stream avec données initiales

StreamBuilder<int>(
stream: compteur(),
initialData: 0, // Valeur affichée avant la première émission
builder: (context, snapshot) {
return Text('Valeur: ${snapshot.data}');
},
)

Exemple : Chat en temps réel

main.dart
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat StreamBuilder',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const ChatPage(),
);
}
}

class Message {
final String auteur;
final String texte;
final DateTime date;

Message({required this.auteur, required this.texte, required this.date});
}

class ChatPage extends StatelessWidget {
const ChatPage({super.key});

Stream<List<Message>> messagesStream() async* {
final messages = <Message>[];

while (true) {
await Future.delayed(const Duration(seconds: 2));

messages.add(Message(
auteur: 'Utilisateur ${messages.length + 1}',
texte: 'Message numéro ${messages.length + 1}',
date: DateTime.now(),
));

yield List.from(messages); // Émettre une copie de la liste
}
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Chat")),
body: StreamBuilder<List<Message>>(
stream: messagesStream(),
initialData: const [],
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text("Erreur : ${snapshot.error}"));
}

final messages = snapshot.data ?? [];

if (messages.isEmpty) {
return const Center(child: Text("Aucun message"));
}

return ListView.builder(
reverse: true, // Afficher les nouveaux messages en bas
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[messages.length - 1 - index];
return ListTile(
leading: CircleAvatar(
child: Text(message.auteur[0]),
),
title: Text(message.auteur),
subtitle: Text(message.texte),
trailing: Text(
'${message.date.hour}:${message.date.minute}',
style: const TextStyle(fontSize: 12),
),
);
},
);
},
),
);
}
}

Comparaison FutureBuilder vs StreamBuilder

AspectFutureBuilderStreamBuilder
DonnéesUne seule valeurFlux continu de valeurs
UtilisationRequêtes HTTP, chargement uniqueTemps réel, mises à jour continues
ConnectionStatenone, waiting, donenone, waiting, active, done
Cas d'usageAPI REST, lecture fichierChat, notifications, capteurs

Bonnes pratiques

1. Initialiser les Future/Stream dans initState


void initState() {
super.initState();
_futureDonnees = chargerDonnees();
_streamController = StreamController<int>();
}

2. Gérer tous les états

builder: (context, snapshot) {
// 1. État de chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}

// 2. État d'erreur
if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
}

// 3. État avec données
if (snapshot.hasData) {
return Text("Données : ${snapshot.data}");
}

// 4. État par défaut
return Text("Aucune donnée");
}

3. Fermer les StreamController


void dispose() {
_streamController.close();
super.dispose();
}

4. Utiliser initialData pour éviter les états vides

StreamBuilder<List<Item>>(
stream: itemsStream,
initialData: [], // Évite les vérifications null
builder: (context, snapshot) {
final items = snapshot.data!; // Toujours non-null
return ListView.builder(...);
},
)

5. Optimiser les reconstructions

Pour les Streams, utilisez distinct() pour éviter les reconstructions inutiles :

stream: myStream.distinct(), // Émet uniquement si la valeur change

Points à retenir

  • FutureBuilder : Pour les opérations uniques (GET API, chargement fichier)
  • StreamBuilder : Pour les flux continus (notifications, temps réel, WebSocket)
  • Toujours gérer : waiting, error, hasData, et l'état par défaut
  • Initialiser dans initState : Éviter les recréations inutiles
  • Fermer les Streams : Dans dispose() pour éviter les fuites mémoire
  • initialData : Utile pour afficher quelque chose immédiatement